
/*
 * Carrot2 project.
 *
 * Copyright (C) 2002-2016, Dawid Weiss, Stanisław Osiński.
 * All rights reserved.
 *
 * Refer to the full license file "carrot2.LICENSE"
 * in the root folder of the repository checkout or at:
 * http://www.carrot2.org/carrot2.LICENSE
 */

package org.carrot2.util.xsltfilter;

import java.io.*;
import java.util.Map;

import javax.servlet.*;
import javax.servlet.http.*;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.stream.StreamResult;

import org.slf4j.Logger;
import org.carrot2.util.xslt.TemplatesPool;
import org.xml.sax.*;
import org.xml.sax.helpers.XMLReaderFactory;

/**
 * A wrapper around a {@link HttpServletResponse} which attempts to detect the type of
 * output acquired from the servlet chain and apply a stylesheet to it if all conditions
 * mentioned in {@link XSLTFilter} are met.
 */
final class XSLTFilterServletResponse extends HttpServletResponseWrapper
{
    private static final Logger log = org.slf4j.LoggerFactory.getLogger(XSLTFilterServletResponse.class);

    /**
     * If true, the stream will be passed verbatim to the next filter. This usually
     * happens when the output has a mime type different than <code>text/xml</code>.
     */
    private boolean passthrough;

    /**
     * The actual {@link HttpServletResponse}.
     */
    private HttpServletResponse origResponse = null;

    /**
     * The actual {@link HttpServletRequest}.
     */
    private HttpServletRequest origRequest;

    /**
     * The {@link ServletOutputStream} returned from {@link #getOutputStream()} or
     * <code>null</code>.
     */
    private ServletOutputStream stream = null;

    /**
     * The {@link PrintWriter} returned from {@link #getWriter()} or <code>null</code>.
     */
    private PrintWriter writer = null;

    /**
     * A pool of stylesheets used for XSLT processing.
     */
    private TemplatesPool transformers;

    /**
     * Servlet context for resolving local paths.
     */
    private ServletContext context;

    /**
     * Creates an XSLT filter servlet response for a single request, wrapping a given
     * {@link HttpServletResponse}.
     * 
     * @param response The original chain's {@link HttpServletResponse}.
     * @param request The original chain's {@link HttpServletRequest}.
     * @param transformers A pool of transformers to be used with this request.
     */
    public XSLTFilterServletResponse(HttpServletResponse response,
        HttpServletRequest request, ServletContext context, TemplatesPool transformers)
    {
        super(response);

        this.origResponse = response;
        this.transformers = transformers;
        this.origRequest = request;
        this.context = context;
    }

    /**
     * We override this method to detect XML data streams.
     */
    public void setContentType(String contentType)
    {
        // Check if XSLT processing has been suppressed for this request.
        final boolean processingSuppressed = processingSuppressed(origRequest);

        if (processingSuppressed)
        {
            // Processing is suppressed.
            log.debug("XSLT processing disabled for the request.");
        }

        if (!processingSuppressed
            && (contentType.startsWith("text/xml") || 
                contentType.startsWith("application/xml")))
        {
            /*
             * We have an XML data stream. Do not enforce the content type. If needed,
             * the XSLT stylesheet can override it via the xsl:output instruction.
             */
            origResponse.setContentType(contentType);
        }
        else
        {
            /*
             * The input is something we won't process anyway, so simply passthrough all
             * data directly to the output stream.
             */
            if (!processingSuppressed)
            {
                log.info("Content type is not text/xml or application/xml ("
                    + contentType + "), passthrough.");
            }

            origResponse.setContentType(contentType);
            passthrough = true;

            // If the output stream is already initialized, passthrough everything.
            if (stream != null && stream instanceof DeferredOutputStream)
            {
                try
                {
                    ((DeferredOutputStream) stream).passthrough(origResponse
                        .getOutputStream());
                }
                catch (IOException e)
                {
                    ((DeferredOutputStream) stream).setExceptionOnNext(e);
                }
            }
        }
    }

    /**
     * Return <code>true</code> if the original request contained XSLT suppressing key.
     * 
     * @see XSLTFilterConstants#NO_XSLT_PROCESSING
     */
    private boolean processingSuppressed(HttpServletRequest origRequest2)
    {
        return (origRequest.getAttribute(XSLTFilterConstants.NO_XSLT_PROCESSING) != null)
            || (origRequest.getParameter(XSLTFilterConstants.NO_XSLT_PROCESSING) != null);
    }

    /**
     * We do not delegate content length because it will most likely change.
     */
    public void setContentLength(final int length)
    {
        log.debug("Original content length (ignored): " + length);
    }

    /**
     * Flush the internal buffers. This only works if XSLT transformation is suppressed.
     */
    public void flushBuffer() throws IOException
    {
        if (stream != null) {
            this.stream.flush();
        }
    }

    /**
     * Return the byte output stream for this response. This is either the original stream
     * or a buffered stream.
     * 
     * @exception IllegalStateException Thrown when character stream has been already
     *                initialized ({@link #getWriter()}).
     */
    public ServletOutputStream getOutputStream() throws IOException
    {
        if (writer != null)
        {
            throw new IllegalStateException(
                "Character stream has been already initialized. Use streams consequently.");
        }

        if (stream != null)
        {
            return stream;
        }

        if (passthrough)
        {
            stream = origResponse.getOutputStream();
        }
        else
        {
            stream = new DeferredOutputStream();
        }

        return stream;
    }

    /**
     * Return the character output stream for this response. This is either the original
     * stream or a buffered stream.
     * 
     * @exception IllegalStateException Thrown when byte stream has been already
     *                initialized ({@link #getOutputStream()}).
     */
    public PrintWriter getWriter() throws IOException
    {
        if (stream != null)
        {
            throw new IllegalStateException(
                "Byte stream has been already initialized. Use streams consequently.");
        }

        if (writer != null)
        {
            return writer;
        }

        if (passthrough)
        {
            writer = this.origResponse.getWriter();
            return writer;
        }

        /*
         * TODO: The character encoding should be extracted in {@link #setContentType()},
         * saved somewhere locally and used here. The response's character encoding may be
         * different (depends on the stylesheet).
         */
        final String charEnc = origResponse.getCharacterEncoding();

        this.stream = new DeferredOutputStream();
        if (charEnc != null)
        {
            writer = new PrintWriter(new OutputStreamWriter(stream, charEnc));
        }
        else
        {
            writer = new PrintWriter(stream);
        }

        return writer;
    }

    /**
     * This method must be invoked at the end of processing. The streams are closed and
     * their content is analyzed. Actual XSLT processing takes place here.
     */
    @SuppressWarnings("unchecked")
    void finishResponse() throws IOException
    {
        if (writer != null)
        {
            writer.close();
        }
        else
        {
            if (stream != null) stream.close();
        }

        /*
         * If we're not in passthrough mode, then we need to finalize XSLT transformation.
         */
        if (false == passthrough)
        {
            if (stream != null)
            {
                final byte [] bytes = ((DeferredOutputStream) stream).getBytes();
                final boolean processingSuppressed = (origRequest
                    .getAttribute(XSLTFilterConstants.NO_XSLT_PROCESSING) != null)
                    || (origRequest.getParameter(XSLTFilterConstants.NO_XSLT_PROCESSING) != null);

                if (processingSuppressed)
                {
                    // Just copy the buffered data to the output directly.
                    final OutputStream os = origResponse.getOutputStream();
                    os.write(bytes);
                    os.close();
                }
                else
                {
                    // Otherwise apply XSLT transformation to it.
                    try
                    {
                        processWithXslt(
                            bytes, 
                            (Map<String, Object>) origRequest.getAttribute(XSLTFilterConstants.XSLT_PARAMS_MAP),
                            origResponse);
                    }
                    catch (TransformerException e)
                    {
                        final Throwable t = unwrapCause(e);
                        if (t instanceof IOException)
                        {
                            throw (IOException) t;
                        }

                        filterError("Error applying stylesheet.", e);
                    }
                }
            }
        }
    }

    /**
     * Unwraps original throwable from the transformer/ SAX stack.
     */
    private Throwable unwrapCause(TransformerException e)
    {
        Throwable t;

        if (e.getException() != null)
        {
            t = e.getException();
        }
        else if (e.getCause() != null)
        {
            t = e.getCause();
        }
        else
        {
            return e;
        }

        do
        {
            if (t instanceof IOException)
            {
                // break early on IOException
                return t;
            }
            else if (t.getCause() != null)
            {
                t = t.getCause();
            }
            else if (t instanceof SAXException
                && ((SAXException) t).getException() != null)
            {
                t = ((SAXException) t).getException();
            }
            else
            {
                return t;
            }
        }
        while (true);
    }

    /**
     * Process the byte array (input XML) with the XSLT stylesheet and push the result to
     * the output stream.
     */
    private void processWithXslt(byte [] bytes,
        final Map<String, Object> stylesheetParams, final HttpServletResponse response)
        throws TransformerConfigurationException, TransformerException, IOException
    {
        final TransformingDocumentHandler docHandler;
        try
        {
            final XMLReader reader = XMLReaderFactory.createXMLReader();

            docHandler = new TransformingDocumentHandler(origRequest, context,
                stylesheetParams, transformers);

            docHandler.setContentTypeListener(new IContentTypeListener()
            {
                public void setContentType(String contentType, String encoding)
                {
                    if (encoding == null)
                    {
                        response.setContentType(contentType);
                    }
                    else
                    {
                        response.setContentType(contentType + "; charset=" + encoding);
                    }
                    try
                    {
                        docHandler.setTransformationResult(new StreamResult(response
                            .getOutputStream()));
                    }
                    catch (IOException e)
                    {
                        throw new RuntimeException("Could not open output stream.");
                    }
                }
            });

            reader.setContentHandler(docHandler);
            try
            {
                reader.parse(new InputSource(new ByteArrayInputStream(bytes)));
            }
            finally
            {
                docHandler.cleanup();
            }
        }
        catch (SAXException e)
        {
            if (log.isDebugEnabled()) {
                StringBuilder sb = new StringBuilder();
                char [] hex = "0123456789abcdef".toCharArray();
                for (int i = 0; i < bytes.length; i++) {
                    int c = bytes[i] & 0xff;
                    sb.append(hex[c >>> 4]);
                    sb.append(hex[c & 0xf]);
                }
                log.debug("Failed to parse the following input (hex-encoded): " 
                    + sb.toString(), e);
            }

            final Exception nested = e.getException();
            if (nested != null)
            {
                if (nested instanceof IOException)
                {
                    throw (IOException) nested;
                }
                else if (nested instanceof TransformerException)
                {
                    throw (TransformerException) nested;
                }
            }

            throw new TransformerException("Input parsing exception.", e);
        }
    }

    /**
     * Attempts to send an internal server error HTTP error, if possible. Otherwise simply
     * pushes the exception message to the output stream.
     * 
     * @param message Message to be printed to the logger and to the output stream.
     * @param t Exception that caused the error.
     */
    protected void filterError(String message, Throwable t)
    {
        log.error("XSLT filter error: " + message, t);
        if (false == origResponse.isCommitted())
        {
            // Reset the buffer and previous status code.
            origResponse.reset();
            origResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            origResponse.setContentType("text/html; charset=UTF-8");
        }

        // Response committed. Just push the error to the output stream.
        try
        {
            final OutputStream os = origResponse.getOutputStream();
            final PrintWriter osw = new PrintWriter(new OutputStreamWriter(os,
                "iso8859-1"));
            osw.write("<html><body><!-- " + XSLTFilterConstants.ERROR_TOKEN + " -->");
            osw.write("<h1 style=\"color: red; margin-top: 1em;\">");
            osw.write("Internal server exception");
            osw.write("</h1>");
            osw.write("<b>URI</b>: " + origRequest.getRequestURI() + "\n<br/><br/>");
            serializeException(osw, t);
            if (t instanceof ServletException
                && ((ServletException) t).getRootCause() != null)
            {
                osw.write("<br/><br/><h2>ServletException root cause:</h2>");
                serializeException(osw, ((ServletException) t).getRootCause());
            }
            osw.write("</body></html>");
            osw.flush();
        }
        catch (IOException e)
        {
            // Not much to do in such case (connection broken most likely).
            log.debug("Filter error could not be returned to client.");
        }
    }

    /**
     * Utility method to serialize an exception and its stack trace to simple HTML.
     */
    private final void serializeException(PrintWriter osw, Throwable t)
    {
        osw.write("<b>Exception</b>: " + t.toString() + "\n<br/><br/>");
        osw.write("<b>Stack trace:</b>");
        osw
            .write("<pre style=\"margin: 1px solid red; padding: 3px; font-family: sans-serif; font-size: small;\">");
        t.printStackTrace(osw);
        osw.write("</pre>");
    }

    /**
     * 
     */
    private void detectErrorResponse(int errorCode)
    {
        if (errorCode != HttpServletResponse.SC_ACCEPTED)
        {
            origRequest
                .setAttribute(XSLTFilterConstants.NO_XSLT_PROCESSING, Boolean.TRUE);
        }
    }

    /**
     * 
     */
    public void sendError(int errorCode) throws IOException
    {
        detectErrorResponse(errorCode);
        super.sendError(errorCode);
    }

    /**
     * 
     */
    public void sendError(int errorCode, String message) throws IOException
    {
        detectErrorResponse(errorCode);
        super.sendError(errorCode, message);
    }

    /**
     * 
     */
    public void setStatus(int statusCode)
    {
        detectErrorResponse(statusCode);
        super.setStatus(statusCode);
    }

    /**
     * 
     */
    public void setStatus(int statusCode, String message)
    {
        detectErrorResponse(statusCode);
        super.setStatus(statusCode, message);
    }
}
